API Design Patterns — 15장 add 및 remove 사용자 메서드 딥다이브 학습 노트
출처: API Design Patterns (JJ Geewax) | 참고: https://google.aip.dev/
15장에서 다루는 내용
- 다대다 관계의 암시적 관리 — 연관 리소스 없이 관계 관리
- 명시적 연관 대비 암시적 연관의 장단점 — 단순함 vs 유연성
- 사용자 메서드를 통한 리소스 연관성 생성 — AddGroupUser, RemoveGroupUser
- 관리 리소스 선택 기준 — 어느 쪽에 add/remove를 붙일 것인가
- 데이터 무결성 문제 대응 — 중복 추가, 미존재 제거
전체 흐름도
[14장: 연관 리소스] [15장: add/remove 메서드]
복잡하지만 유연 단순하지만 제한적
┌──────────────────────┐ ┌──────────────────────┐
│ Membership 리소스 │ │ 연관 리소스 없음 │
│ - CRUD 5개 메서드 │ │ - add/remove 2개 │
│ - 별칭 2개 │ │ - list 2개 │
│ - 메타데이터 저장 │ │ - 메타데이터 없음 │
│ 총 7개 메서드 │ │ 총 4개 메서드 │
└──────────────────────┘ └──────────────────────┘
↓ 어떤 것을 선택?
┌──────────────────────────────────────┐
│ 관계에 메타데이터(역할, 가입일)가 │
│ 필요한가? │
│ │
│ YES → 14장 연관 리소스 │
│ NO → 15장 add/remove (더 단순!) │
└──────────────────────────────────────┘
선수 지식 체크리스트
- [ ] REST API 기본 개념 (리소스, 엔드포인트, HTTP 메서드)
- [ ] 사용자 메서드 (9장) — add/remove는 사용자 메서드의 일종
- [ ] 연관 리소스 (14장) — add/remove가 대안으로 제시되는 대상
핵심 키워드
| 키워드 | 의미 |
|---|---|
| 관리 리소스 | 관계를 관리하는 역할의 리소스 (add/remove 메서드가 붙는 쪽) |
| 연관 대상 리소스 | 관리 리소스에 추가/제거되는 리소스 |
| 암시적 서브컬렉션 | 실제 서브리소스가 아니지만 서브컬렉션처럼 보이는 URL 구조 |
| 비상호관계 | 관리 리소스가 한쪽에만 있어 양방향 대칭이 아닌 상태 |
15.1 서론 — 왜 더 단순한 대안이 필요한가?
한 줄 요약
연관 리소스(14장)가 너무 복잡할 때, 약간의 제한을 감수하고 더 단순하게 다대다 관계를 관리하자!
쉬운 설명 (카카오톡 그룹채팅 비유)
카카오톡 그룹채팅에서 멤버를 관리할 때:
- 14장 방식: "멤버십 카드"를 만들어서 역할, 가입일, 알림 설정 등을 관리 → 복잡하지만 유연
- 15장 방식: 그냥 "초대"하고 "내보내기"만! → 단순하지만 추가 정보 없음
14장: CreateMembership({ userId: "김철수", groupId: "반모임", role: "admin" })
→ Membership 리소스 생성, 이후 Get/Update/Delete/List 5개 메서드 추가
15장: AddGroupUser({ parent: "groups/반모임", userId: "users/김철수" })
→ 끝! 추가 리소스 없음. add/remove/list만 있으면 됨
실무 예제 — 메타데이터가 불필요한 다대다 관계
1. 소셜 미디어 팔로우
User ↔ User (팔로우한다/안 한다, 역할 없음)
→ AddUserFollower / RemoveUserFollower
2. 게시글 태그
Post ↔ Tag (태그가 붙어있다/없다)
→ AddPostTag / RemovePostTag
3. 상품 카테고리
Product ↔ Category (소속 여부만)
→ AddCategoryProduct / RemoveCategoryProduct
4. 재생목록 영상
Playlist ↔ Video (포함 여부만)
→ AddPlaylistVideo / RemovePlaylistVideo
5. 사용자 역할 (역할이 단순 문자열이 아닌 별도 리소스일 때)
User ↔ Role (가지고 있다/없다)
→ AddUserRole / RemoveUserRole
핵심 체크포인트
- ✅ 연관 리소스(14장)의 더 단순한 대안
- ✅ 관계 메타데이터가 불필요할 때 적합
- ✅ 클라이언트가 연관 리소스를 직접 다룰 필요 없음
- ✅ 메서드 4개로 다대다 관계 관리 가능
15.2 개괄 — add/remove 패턴의 원리
한 줄 요약
관계를 구성하는 리소스를 숨기고, add/remove 사용자 메서드로 연관성을 생성/삭제한다.
2가지 제약 조건
| 제약 | 설명 | 비유 |
|---|---|---|
| 메타데이터 없음 | 관계의 존재 여부만 저장. 역할, 가입일 등 추가 정보 불가 | 전화번호부에 이름만 있고 관계(친구/동료) 없음 |
| 관리 리소스 선택 | 한쪽 리소스만 관리자 역할. 양쪽 모두 불가 | 카톡 그룹방 방장만 초대/강퇴 가능 |
관리 리소스 선택
방식 1: 그룹이 관리 리소스 (그룹이 사용자를 관리)
─────────────────────────────────────────────────
POST /groups/1/users:add { userId: "users/alice" } ← "그룹에 사용자를 추가"
POST /groups/1/users:remove { userId: "users/alice" } ← "그룹에서 사용자를 제거"
GET /groups/1/users ← "그룹의 사용자 목록"
GET /users/alice/groups ← "사용자의 그룹 목록"
방식 2: 사용자가 관리 리소스 (사용자가 그룹을 관리)
─────────────────────────────────────────────────
POST /users/alice/groups:add { groupId: "groups/1" } ← "사용자에 그룹을 추가"
POST /users/alice/groups:remove { groupId: "groups/1" } ← "사용자에서 그룹을 제거"
GET /users/alice/groups ← "사용자의 그룹 목록"
GET /groups/1/users ← "그룹의 사용자 목록"
관리 리소스 선택 기준
질문: "누가 관계를 관리하는 것이 자연스러운가?"
┌───────────────────────────────────────────────────────────┐
│ 관계 │ 관리 리소스 │ 이유 │
├───────────────────────────────────────────────────────────┤
│ 그룹 ↔ 사용자 │ Group │ "그룹에 사람을 초대" │
│ 재생목록 ↔ 동영상 │ Playlist │ "재생목록에 영상 추가"│
│ 게시글 ↔ 태그 │ Post │ "게시글에 태그 달기" │
│ 사용자 ↔ 팔로워 │ User (대상) │ "이 사용자를 팔로우" │
│ 상품 ↔ 카테고리 │ Category │ "카테고리에 상품 배치"│
│ 쇼핑카트 ↔ 상품 │ Cart │ "장바구니에 상품 담기"│
└───────────────────────────────────────────────────────────┘
핵심: "A에 B를 추가/제거한다"가 자연스러운 쪽이 관리 리소스
핵심 체크포인트
- ✅ 어느 쪽을 관리 리소스로 선택하든 하나만 선택
- ✅ 선택 기준: "A에 B를 추가"가 자연스러운 쪽이 A (관리 리소스)
- ✅ 양쪽 모두 add/remove를 제공하면 안 됨 (중복, 혼란)
15.3 구현
add 및 remove 메서드
한 줄 요약
9장의 사용자 메서드 패턴(콜론+동작명)을 따라 add/remove를 구현한다.
상세 API 호출 예시
표 15.1 Add 및 remove 메서드 요약
┌──────────────────┬───────────────────────────┬─────────────────────────────┐
│ 액션 │ 메서드 │ HTTP │
├──────────────────┼───────────────────────────┼─────────────────────────────┤
│ Alice를 │ AddGroupUser({ │ POST /groups/1/users:add │
│ 그룹 1에 추가 │ parent: "groups/1", │ Body: { userId: "users/1" } │
│ │ userId: "users/1" │ │
│ │ }); │ → 200 OK (반환값 없음) │
├──────────────────┼───────────────────────────┼─────────────────────────────┤
│ Alice를 │ RemoveGroupUser({ │ POST /groups/1/users:remove │
│ 그룹 1에서 제거 │ parent: "groups/1", │ Body: { userId: "users/1" } │
│ │ userId: "users/1" │ │
│ │ }); │ → 200 OK (반환값 없음) │
└──────────────────┴───────────────────────────┴─────────────────────────────┘
네이밍 규칙: Add/Remove + 관리리소스(단수) + 대상리소스(단수)
- AddGroupUser (그룹에 사용자 추가)
- RemoveGroupUser (그룹에서 사용자 제거)
- AddPlaylistVideo (재생목록에 동영상 추가)
- RemovePostTag (게시글에서 태그 제거)
요청에 식별자만 포함하는 이유
// ❌ 잘못된 설계: 전체 리소스를 본문에 포함
POST /groups/1/users:add
{
user: {
id: "users/alice",
name: "Alice", // ← 불필요한 데이터
email: "[email protected]" // ← 이걸 보내면 User도 업데이트하는 건가? 혼란!
}
}
// ✅ 올바른 설계: 식별자만 포함
POST /groups/1/users:add
{
userId: "users/alice" // ← 추가할 대상의 식별자만!
}
핵심: 나머지 정보가 본디 목적(관계 수립)과 무관하며, 클라이언트가 동시에 리소스 업데이트도 가능하다고 오판할 수 있기 때문.
15.3.1 연관 리소스 나열
한 줄 요약
양방향 list 메서드로 "그룹의 사용자들"과 "사용자의 그룹들"을 모두 조회 가능.
표 15.2 list 메서드 요약
┌──────────────────────┬────────────────────────────┬─────────────────────┐
│ 질문 │ 메서드 │ HTTP │
├──────────────────────┼────────────────────────────┼─────────────────────┤
│ 그룹 1에 속한 │ ListGroupUsers({ │ GET /groups/1/users │
│ 사용자는? │ parent: "groups/1" │ │
│ │ }) │ │
│ │ → User[] 반환 │ │
├──────────────────────┼────────────────────────────┼─────────────────────┤
│ 사용자 1이 속한 │ ListUserGroups({ │ GET /users/1/groups │
│ 그룹은? │ parent: "users/1" │ │
│ │ }) │ │
│ │ → Group[] 반환 │ │
└──────────────────────┴────────────────────────────┴─────────────────────┘
비유: 암시적 서브컬렉션
URL: GET /groups/1/users
이 URL만 보면 "users"가 Group의 서브리소스 같지만,
실제로는 서브리소스가 아니다!
→ Group 리소스 아래에 User 리소스가 중첩된 것이 아님
→ "그룹 1과 연관된 사용자 목록"을 보여주는 편의 엔드포인트
이것을 "암시적 서브컬렉션"이라고 한다.
실제 서브리소스: /groups/1/settings (Group이 소유)
암시적 서브컬렉션: /groups/1/users (연관된 User 목록)
페이징과 필터링
// 그룹에 사용자가 수천 명일 수 있으므로 페이징 지원
GET /groups/1/users?maxPageSize=50
→ {
users: [{ id: "users/alice", ... }, ...], // 최대 50명
nextPageToken: "eyJ0..." // 다음 페이지 토큰
}
// 다음 페이지
GET /groups/1/users?maxPageSize=50&pageToken=eyJ0...
→ {
users: [...],
nextPageToken: null // 마지막 페이지
}
15.3.2 데이터 무결성
한 줄 요약
중복 추가는 409 Conflict, 미존재 제거는 412 Precondition Failed.
시나리오별 동작
시나리오 1: 정상 추가
─────────────────────────────────────────────
POST /groups/1/users:add { userId: "users/jimmy" }
→ 200 OK ✅ (Jimmy가 그룹 1에 추가됨)
시나리오 2: 중복 추가
─────────────────────────────────────────────
POST /groups/1/users:add { userId: "users/jimmy" }
→ 200 OK ✅ (처음이니까 성공)
POST /groups/1/users:add { userId: "users/jimmy" }
→ 409 Conflict ❌ (이미 멤버입니다!)
→ 멱등성 활용: 성공(200)이든 충돌(409)이든,
결과적으로 Jimmy는 그룹 1의 멤버. 목적 달성!
시나리오 3: 정상 제거
─────────────────────────────────────────────
POST /groups/1/users:remove { userId: "users/jimmy" }
→ 200 OK ✅ (Jimmy가 그룹 1에서 제거됨)
시나리오 4: 미존재 제거
─────────────────────────────────────────────
POST /groups/1/users:remove { userId: "users/unknown" }
→ 412 Precondition Failed ❌
("이 사용자는 이 그룹의 멤버가 아닙니다")
시나리오 5: 존재하지 않는 사용자 추가
─────────────────────────────────────────────
POST /groups/1/users:add { userId: "users/nonexistent" }
→ 404 Not Found ❌ ("해당 사용자가 존재하지 않습니다")
14장의 고유성 제약과 비교
14장 (연관 리소스):
CreateMembership({ userId: "jimmy", groupId: "1" }) → 201 Created
CreateMembership({ userId: "jimmy", groupId: "1" }) → 409 Conflict
→ Membership 리소스가 명시적이므로 중복 검증도 명확
15장 (add/remove):
AddGroupUser({ parent: "groups/1", userId: "users/jimmy" }) → 200 OK
AddGroupUser({ parent: "groups/1", userId: "users/jimmy" }) → 409 Conflict
→ 내부적으로 관계를 저장하지만 리소스로 노출하지 않음
→ 중복 검증 로직은 동일하지만 클라이언트에게는 더 단순
15.3.3 최종 API 정의
abstract class GroupApi {
static version = "v1";
static title = "Group API (add/remove 패턴)";
// === User 표준 메서드 ===
@post("/users")
CreateUser(req: CreateUserRequest): User;
@get("/{id=users/*}")
GetUser(req: GetUserRequest): User;
@get("/users")
ListUsers(req: ListUsersRequest): ListUsersResponse;
@patch("/{resource.id=users/*}")
UpdateUser(req: UpdateUserRequest): User;
@delete("/{id=users/*}")
DeleteUser(req: DeleteUserRequest): void;
// === Group 표준 메서드 ===
@post("/groups")
CreateGroup(req: CreateGroupRequest): Group;
@get("/{id=groups/*}")
GetGroup(req: GetGroupRequest): Group;
@get("/groups")
ListGroups(req: ListGroupsRequest): ListGroupsResponse;
@patch("/{resource.id=groups/*}")
UpdateGroup(req: UpdateGroupRequest): Group;
@delete("/{id=groups/*}")
DeleteGroup(req: DeleteGroupRequest): void;
// === add/remove 사용자 메서드 ===
@post("{parent=groups/*}/users:add")
AddGroupUser(req: AddGroupUserRequest): void;
@post("{parent=groups/*}/users:remove")
RemoveGroupUser(req: RemoveGroupUserRequest): void;
// === 양방향 list ===
@get("{parent=groups/*}/users")
ListGroupUsers(req: ListGroupUsersRequest): ListGroupUsersResponse;
@get("{parent=users/*}/groups")
ListUserGroups(req: ListUserGroupsRequest): ListUserGroupsResponse;
}
// === 리소스 인터페이스 ===
interface Group {
id: string; // "groups/engineering"
name: string;
description: string;
userCount: number; // 사용자 수 (인라인 목록 아님!)
}
interface User {
id: string; // "users/alice"
displayName: string;
emailAddress: string;
}
// === 요청 인터페이스 ===
interface AddGroupUserRequest {
parent: string; // 관리 리소스 식별자: "groups/1"
userId: string; // 추가할 사용자: "users/alice"
}
interface RemoveGroupUserRequest {
parent: string; // 관리 리소스 식별자: "groups/1"
userId: string; // 제거할 사용자: "users/alice"
}
// === 응답 인터페이스 ===
interface ListGroupUsersResponse {
users: User[]; // 사용자 목록 (Membership이 아닌 User!)
nextPageToken: string;
}
interface ListUserGroupsResponse {
groups: Group[]; // 그룹 목록 (Membership이 아닌 Group!)
nextPageToken: string;
}
API 메서드 카운트
User 관련: 5개 (Create, Get, List, Update, Delete)
Group 관련: 5개 (Create, Get, List, Update, Delete)
관계 관련: 4개 (Add, Remove, ListGroupUsers, ListUserGroups)
────────────────────────────────────────────
총 14개 메서드 (14장의 17개보다 3개 적음!)
핵심 체크포인트
- ✅ add/remove는 사용자 메서드 (POST + 콜론 구분자)
- ✅ 요청에 식별자만 포함 (전체 리소스 아님)
- ✅ list 메서드는 User[]/Group[] 반환 (Membership이 아님)
- ✅ 중복 추가: 409 Conflict, 미존재 제거: 412 Precondition Failed
- ✅ 양방향 list 지원 (ListGroupUsers + ListUserGroups)
15.4 트레이드오프
15.4.1 비상호관계 (Nonreciprocity)
관리 리소스를 하나만 선택해야 하므로 비대칭적이다.
Group이 관리 리소스일 때:
✅ POST /groups/1/users:add — "그룹에 사용자를 추가" (자연스러움)
❌ POST /users/alice/groups:add — 이 메서드는 존재하지 않음!
→ 클라이언트가 "Alice를 engineering 그룹에 넣고 싶다"면
POST /groups/engineering/users:add를 호출해야 함
POST /users/alice/groups:add는 불가!
비유: 카톡 그룹방에서 방장만 초대할 수 있는 것과 같다.
일반 멤버가 "나를 이 그룹에 추가해줘"라고 할 수 없음.
15.4.2 관계 메타데이터 불가
14장 (연관 리소스): 관계에 추가 정보 저장 가능
{
userId: "alice",
groupId: "engineering",
role: "admin", ← 역할
joinedAt: "2024-03-15", ← 가입일
expireTime: "2025-03-15" ← 만료일
}
15장 (add/remove): 관계의 존재 여부만
"Alice는 engineering의 멤버이다" (O)
"Alice는 engineering의 admin이다" (X) ← 표현 불가!
"Alice가 2024-03-15에 가입했다" (X) ← 표현 불가!
해결이 필요하면? → 14장의 연관 리소스로 전환하거나, 관계와 별도로 메타데이터를 저장하는 다른 방법을 고안해야 한다.
15.4.3 14장 vs 15장 선택 의사결정 트리
Q1: 관계에 메타데이터(역할, 가입일 등)가 필요한가?
├── YES → 14장 연관 리소스 (Membership 패턴)
│
└── NO → Q2: API 복잡도를 최소화하고 싶은가?
├── YES → 15장 add/remove
└── NO → 14장 연관 리소스 (메타데이터 없이도 사용 가능)
Q3: 나중에 메타데이터가 필요해질 가능성이 높은가?
├── YES → 처음부터 14장으로 시작 (마이그레이션 비용 방지)
└── NO → 15장으로 시작, 필요 시 14장으로 전환
15.5 연습문제 풀이
Q1: Recipe와 Ingredient의 다대다 관계에서 관리 리소스는?
A: Recipe가 관리 리소스이다. "레시피에 재료를 추가한다"가 자연스럽다. - AddRecipeIngredient, RemoveRecipeIngredient - ListRecipeIngredients (이 레시피에 필요한 재료) - ListIngredientRecipes (이 재료를 사용하는 레시피)
Q2: 팔로우 관계에서 관리 리소스는?
A: 팔로우를 받는 사용자가 관리 리소스이다. "이 사용자에 팔로워를 추가/제거"가 시스템 관점에서 자연스럽다. 또는 팔로우를 하는 사용자가 관리 리소스: AddUserFollowing, RemoveUserFollowing. 도메인에 따라 다르므로 팀과 합의 필요.
Q3: "Alice가 engineering 그룹에서 admin 역할을 갖는다"를 15장 패턴으로 표현할 수 있는가?
A: 표현할 수 없다. 15장 패턴은 관계의 존재 여부만 저장하므로 역할 정보를 포함할 방법이 없다. 이 경우 14장의 연관 리소스(Membership 리소스에 role 필드)를 사용해야 한다.
Q4: add 메서드 호출 시 409 Conflict를 받으면 클라이언트는 어떻게 처리해야 하는가?
A: 목적이 "관계를 만드는 것"이면 409도 성공으로 취급할 수 있다. 이미 관계가 존재한다는 뜻이므로 목적은 달성된 것이다. 멱등적 처리 패턴.
부록 A: 용어 사전
| 용어 | 영문 | 의미 |
|---|---|---|
| 관리 리소스 | Managing Resource | 관계를 관리하는 역할의 리소스 (add/remove가 붙는 쪽) |
| 연관 대상 리소스 | Associated Resource | 관리 리소스에 추가/제거되는 리소스 |
| 암시적 서브컬렉션 | Implicit Sub-collection | 실제 서브리소스 없이 URL만 서브컬렉션처럼 보이는 구조 |
| 비상호관계 | Nonreciprocity | 관리 리소스가 한쪽에만 있어 양방향 대칭이 아닌 상태 |
| 멱등성 | Idempotency | 같은 요청을 여러 번 보내도 결과가 동일한 성질 |
부록 B: 핵심 비교표 — 14장 vs 15장
| 항목 | 14장 연관 리소스 | 15장 add/remove |
|---|---|---|
| 관계 리소스 | Membership (별도 리소스) | 없음 (숨김) |
| 관계 관련 메서드 수 | 최대 7개 | 4개 |
| 메타데이터 | ✅ 저장 가능 | ❌ 불가 |
| 복잡도 | 높음 | 낮음 |
| 양방향 쿼리 | ✅ 별칭 메서드 | ✅ list 메서드 |
| 관계 대칭성 | ✅ 대칭 | ❌ 비대칭 (관리 리소스 하나) |
| list 반환 타입 | Membership[] | User[]/Group[] (리소스 직접) |
| 중복 생성 | 409 Conflict | 409 Conflict |
| 적합한 경우 | 메타데이터 필요 시 | 존재 여부만 필요 시 |
부록 C: 추천 참고 자료 & 링크
| 자료 | 설명 |
|---|---|
| AIP-142 (google.aip.dev) | Google의 시간 기반 변경 컬렉션 패턴 |
| 9장 사용자 메서드 | add/remove의 기반이 되는 사용자 메서드 개념 |
| 14장 연관 리소스 | 유연한 다대다 관계 (add/remove의 상위 호환) |
| 13장 교차 참조 | 일대다 관계의 기본 참조 패턴 |
클릭하거나 Space를 눌러 뒤집기